Skip to content

3.3. MCP

In one glance

  • You will: Run the agent's read tools as a standalone MCP server, probe it from a second terminal, and see what changes when the agent talks to them over HTTP.
  • You need: 3.1. Tools finished, plus curl on your PATH (the base mise run doctor does not check it).
  • Time: about 30 minutes, hands-on.

What problem does MCP solve?

Until 3.2. Skills, every tool the agent had was a Python function it imported. That works precisely as long as the tool, the agent, and the framework live in one process and one language. The moment any of those differ, you are writing glue.

The problem generalizes badly. With M agents and N tools, integrating each agent with each tool by hand is M × N bespoke adapters. Each one has to re-solve the same three things: discovery ("what can you do?"), invocation ("call this with these arguments"), and schema description. Every framework grew its own incompatible answer, so a tool built for one agent was worthless to another.

The Model Context Protocol replaces that with one contract: M + N. A tool speaks MCP once and every MCP-capable client can use it; an agent speaks MCP once and every MCP server is available to it. It is a protocol in the same sense as LSP, the Language Server Protocol that lets any editor talk to any language server. MCP was donated to the Agentic AI Foundation, so it belongs to no single vendor.

flowchart LR
    subgraph Without["Without MCP: M x N adapters"]
        A1[Agent A] --- T1[Tool 1]
        A1 --- T2[Tool 2]
        A2[Agent B] --- T1
        A2 --- T2
    end
    subgraph With["With MCP: M + N"]
        B1[Agent A] --> P{{MCP}}
        B2[Agent B] --> P
        P --> S1[Tool 1]
        P --> S2[Tool 2]
    end

What are the parts of MCP?

MCP has three roles, and keeping them straight makes the rest of the chapter read easily:

Role What it is Here
Host The application the user interacts with The AgentOps Agent process
Client The connector inside the host that speaks MCP to one server ADK's McpToolset
Server The process exposing capabilities mcp_server.py, and any third-party server

A server can offer three kinds of capability. The distinction matters because they have different trust properties:

  • Tools — functions the model may choose to invoke. Model-controlled, and therefore the surface that needs authorization. This course exposes six.
  • Resources — read-only data the host may attach to context (files, records). Application-controlled: the app decides, not the model.
  • Prompts — reusable templates the user may invoke deliberately. User-controlled.

Those three all point the same way: the client asks, the server answers. The pinned client also implements three capabilities that point the other way, and they are the ones that change the threat model:

Client capability What the server may ask your client to do What it spends
Sampling Run a model completion on the server's behalf Your token budget, your provider, your rate limit
Elicitation Prompt your human user for input mid-call Your user's attention, and their trust in your UI
Roots Read which filesystem/URI roots you consider in scope Reconnaissance about the host you are running on

A capability is negotiated in initialize: a client that never declares one can never be asked for it. That is the whole defense, and it is a declaration you control.

Underneath, MCP is JSON-RPC 2.0: a small convention for calling named methods with JSON arguments over any pipe. A handshake-era client calls initialize, then tools/list to discover what exists, then tools/call to invoke one. A 2026-07-28 client skips the handshake: every request carries its protocol revision, and server/discover answers "what do you support?" on demand. Either way discovery is dynamic: the agent asks the server what it can do at runtime, instead of having it hard-coded at import time.

Run the deterministic server/client contract before starting a transport:

cd agents/python
uv run pytest tests/test_mcp.py -q

How do you inspect the MCP server without a model?

Before reading more theory, start the server. It is a plain HTTP process, so you can exercise its readiness before wiring any model. Start the Streamable HTTP transport:

cd agents/python
mise run mcp:http

Leave the server running. From a second terminal, probe both health endpoints:

curl -fsS http://127.0.0.1:8000/healthz
curl -fsS http://127.0.0.1:8000/livez

If the writer has published runtime state, /healthz returns {"status":"ready"}; on a fresh checkout it returns 503 {"status":"unready","problems":[...]} — the correct read-replica ordering explained below, not a bug. /livez always returns {"status":"alive"}.

The MCP tool surface itself is exercised elsewhere. The JSON-RPC initialize → tools/list → tools/call handshake that returns the same six read tools is driven end to end by tests/test_mcp.py, which is the offline evidence that the wire contract holds without a model in the loop.

Now that a server is answering, the rest of the page explains what it exposes, how a client reaches it, and what changes when the agent uses it.

Why does MCP matter beyond this repository?

Because it decouples the tool from the agent's lifecycle, and that changes what you can build:

  • Reuse. The same six read tools serve this ADK agent, someone else's LangGraph agent, and a desktop assistant, with no code change.
  • Isolation. The tool runs in its own process — its own dependencies, its own permissions, its own crash blast radius, and its own language.
  • A governable boundary. Once tool calls cross a process boundary as protocol messages, something can sit in the middle and enforce policy on them. That is exactly what Chapter 5 does with agentgateway, and it is impossible when tools are Python imports.
  • An ecosystem. Public MCP servers exist for GitHub, Postgres, filesystems, and much else, so "give the agent a new capability" is often configuration rather than code.

A protocol is not a security model

MCP standardizes how a tool is described and called. It says nothing about whether calling it is a good idea. Authentication, authorization, transport security, timeouts, rate limits, and schema validation remain yours to enforce.

Connecting to a third-party MCP server also means letting an untrusted party put tool descriptions into your model's prompt, which is a prompt-injection surface (4.6. Security). This course keeps writes out of MCP entirely for exactly that reason, and puts a policy gateway in front of the reads.

The other direction matters too. Under the handshake-era revisions, a third-party server can send requests to your client mid-call: sampling spends your token budget, and elicitation puts a prompt of the server's choosing in front of your user. Revision 2026-07-28 removes server-to-client requests; a tool that needs input returns the question as a result, and your client decides whether to answer. Either way the decision stays with the client. This course's client declares no sampling, elicitation, or roots capability, so a server that asks gets a protocol error rather than a completion. Declare a capability only when you can also answer "and what happens when the server abuses it?"

Which tools does the course server expose?

Only six read capabilities cross the MCP boundary. mcp_server.py re-exposes the raw tool functions, and lets MCPServer derive each JSON schema from the same type hints and docstrings the ADK function tools already carry:

for _tool in (
    tools.list_incidents,
    tools.get_incident,
    tools.get_service_status,
    tools.search_service_logs,
    memory.get_runbook,
    memory.search_runbooks,
):
    mcp.add_tool(_tool, annotations=READ_ONLY)

The excerpt is build-checked against mcp_server.py. restart_service and resolve_incident remain in-process because their ADK confirmation context and audit identity are part of the write boundary.

Each tool also carries tool annotations: client-visible hints that it is read-only, idempotent, and closed-world. They help a client present the tool honestly, but the MCP specification tells clients never to trust annotations from an untrusted server. The enforcement stays elsewhere: the client allowlist, the gateway policy, and writes that never leave the process. 2.6. Workshop step 8 has you build the same boundary for your own three read tools.

Separating read and write surfaces makes authorization easier to reason about. The entire MCP surface is idempotent by construction — running any call twice changes nothing — so a compromised or misbehaving client can read state but never change it.

Note what does not cross the boundary: these are the bare functions, not the with_resilience-wrapped versions the in-process agent registers (4.5. Guardrails). Retry and deadline bounds move to the client side when the tools become remote — see What changes when you flip AGENT_MCP_URL? below.

Which revision of MCP are you speaking?

"We both speak MCP" is not an interoperability statement. MCP is versioned by dated revisions. Up to 2025-11-25, the revision is negotiated in initialize: the client proposes a protocolVersion, and the server answers with the one it will speak. From 2026-07-28, each request names its revision, and server/discover lists the revisions a server supports. Either way the choice is explicit rather than a silent degrade — which is the good outcome.

Three facts locate this course on that timeline:

  • The server runs MCP Python SDK 2.x, which speaks 2026-07-28 and still serves every earlier revision from the same process. agents/python/pyproject.toml opts in explicitly, because ADK still resolves the 1.x SDK by default.
  • ADK's McpToolset still opens a session with initialize, so the agent itself negotiates 2025-11-25, the newest handshake revision. load/mcp-read.js sends the same string, so the load test measures the platform rather than a version negotiation. mise run smoke:host crosses agentgateway in both eras: the handshake reaches 2025-11-25, and the SDK's own Client reaches 2026-07-28.
  • No revision is forever. Treat "which revision do we speak?" as a released fact about your system, next to your image digest and your lockfile, and write it down where an integrator will look.

That is the concrete form of the warning in 0.3. Ecosystem: two implementations can each claim MCP and still disagree on a version. The disagreement surfaces at initialize if you are lucky, and at a missing field three calls later if you are not.

Which transports are supported?

Two transports matter here: stdio for a local child process, streamable HTTP for a networked one. mcp_server.py defaults to stdio.

MCP_TRANSPORT still accepts sse, and that is a compatibility affordance, not a third choice. Server-Sent Events was MCP's original HTTP transport; streamable HTTP replaced it, and the protocol has carried SSE as deprecated since. Reach for it only to talk to a client that has not migrated, and do not design a new deployment around it.

Use the repository tasks:

cd agents/python
mise run mcp       # stdio
mise run mcp:http  # streamable HTTP on 127.0.0.1:8000/mcp

The Kubernetes MCP deployment uses the same module with MCP_HOST=0.0.0.0, port 8000, and streamable HTTP.

Deeper: hardening and shutdown on the HTTP transport

Two defenses and one shutdown policy sit on the HTTP path. DNS rebinding is the attack where a hostile page points its own domain at your loopback address to reach a local server, and checking the Host header is what stops it. 421 Misdirected Request is the HTTP status that says "you reached the wrong server".

mcp_server.py serves streamable HTTP — and the deprecated SSE path — from a single MCPServer. SDK 2.x configures each transport when it builds the ASGI app, so the server object carries no host, port, or security settings of its own:

HOST = os.environ.get("MCP_HOST", "127.0.0.1")
PORT = int(os.environ.get("MCP_PORT", "8000"))
# MCP SDK 2.x configures transports when the ASGI app is built, not on the server object.
TRANSPORT_SECURITY = TransportSecuritySettings(
    enable_dns_rebinding_protection=True,
    allowed_hosts=_allowed_hosts(),
    allowed_origins=list(_ALLOWED_ORIGINS),
)
# Annotations are client-visible hints, not enforcement: the client allowlist, gateway
# policy, and in-process guarded writes remain the security boundary.
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False)

mcp = MCPServer("agentops-agent")

stateless_http=True, passed when the streamable HTTP app is built, is a deployment decision, not a default: the transport keeps no per-session server state, so any replica can answer any request. Behind agentgateway that removes the need for session affinity or sticky routing — a tools/call can land on a different pod than the initialize that preceded it. The 2026-07-28 revision makes that property part of the protocol itself. The SDK also rejects request bodies over 4 MiB with HTTP 413 unless you raise the limit.

HTTP transport keeps the SDK's DNS-rebinding protection enabled even when it binds to all interfaces. The secure defaults accept loopback authorities, the container-bridge host host.docker.internal, and the course's agentgateway/agentops-mcp service names; browser origins remain limited to loopback HTTP. An untrusted Host header is rejected with 421 Misdirected Request before any tool runs. MCP_ALLOWED_HOSTS is a comma-separated full override, not an addition, so each deployment can narrow the authorities it expects without ever falling back to * — and an empty override is a startup error, not a silent open door.

The HTTP transports share the same bounded graceful-shutdown policy as A2A:

def _run_http(transport: Literal["sse", "streamable-http"]) -> None:
    """Serve MCP HTTP with the same bounded SIGTERM drain as A2A."""
    app = (
        mcp.sse_app(transport_security=TRANSPORT_SECURITY)
        if transport == "sse"
        else mcp.streamable_http_app(stateless_http=True, transport_security=TRANSPORT_SECURITY)
    )
    uvicorn.run(
        app,
        host=HOST,
        port=PORT,
        log_level=mcp.settings.log_level.lower(),
        timeout_graceful_shutdown=int(settings.drain_timeout_s),
    )


def main() -> None:
    """Run over stdio by default or bounded Uvicorn for HTTP transports."""
    transport = os.environ.get("MCP_TRANSPORT", "stdio")
    if transport not in {"stdio", "sse", "streamable-http"}:
        raise ValueError(f"Unsupported MCP_TRANSPORT: {transport!r}")
    if transport == "stdio":
        mcp.run("stdio")
        return
    _run_http(transport)

The Uvicorn timeout_graceful_shutdown is AGENT_DRAIN_TIMEOUT_S (default 10s), so a SIGTERM lets in-flight tool calls finish before the process exits rather than dropping them. Kubernetes' terminationGracePeriodSeconds must exceed it.

How does the MCP server report readiness?

Readiness answers "should traffic be sent here?". Liveness answers "would restarting help?". The server answers each on its own route, and the distinction between the two probes is deliberate:

  • /healthz (readiness) calls probe_runtime_database(), which opens the runtime SQLite copy read-only, runs PRAGMA quick_check, and verifies the required tables plus the current audit version column and exact non-partial unique ascending BINARY idempotency index. Any failure class returns 503 {"status": "unready", ...}, so missing, corrupt, legacy, or failed-migration state is never served.
  • /livez (liveness) returns {"status": "alive"} unconditionally — trivial by design, because a restart only helps a wedged process, not a missing dataset.

That is why /healthz can legitimately fail on a fresh checkout while /livez still answers.

Deeper: the probe code, and why a fresh server stays unready

An HTTP MCP pod needs health probes, and mcp_server.py registers two Starlette routes on the same app. MCPServer serves custom routes without auth — suitable exactly for probes, which must answer before any token exchange:

# simplified
@mcp.custom_route("/healthz", methods=["GET"])
async def healthz(request: Request) -> JSONResponse:
    """Readiness: the agent-owned runtime database is readable and valid."""
    del request
    try:
        probe_runtime_database()
    except Exception as error:  # readiness reports every failure class as unready
        return JSONResponse(
            {"status": "unready", "problems": [f"dataset unavailable: {type(error).__name__}"]},
            status_code=503,
        )
    return JSONResponse({"status": "ready"})

One subtlety worth internalizing: the read-only MCP pod does not initialize or migrate state. probe_runtime_database() reports "not initialized" rather than copying the seed, and reports legacy audit schema as unready rather than fixing it. The A2A owner publishes and prepares the runtime database first (3.6. A2A); tests/test_mcp.py proves neither failure path changes the file.

How does the MCP helper choose a transport?

When code constructs ops_mcp_toolset, a URL selects streamable HTTP and no URL selects a local stdio child. The conversational agent constructs this helper only when AGENT_MCP_URL is set; otherwise it registers direct Python read tools.

def ops_mcp_toolset(url: str | None = None) -> McpToolset:
    """Return the toolset over local stdio or a gateway streamable-HTTP URL.

    Both transports carry the course's explicit deadlines (Chapter 4.5): a hung
    MCP server or gateway then fails a tool call fast instead of hanging a turn.
    ``tool_filter`` pins which tools may be offered, so a server cannot widen the
    agent's surface — or reach the model with new description text — by adding one.
    """
    endpoint = url or settings.mcp_url
    if endpoint:
        # A secured gateway route (Ch. 5.5) authenticates the caller by bearer
        # token; the default local route needs no header.
        headers = {"Authorization": f"Bearer {settings.mcp_token.get_secret_value()}"} if settings.mcp_token else None
        return McpToolset(
            connection_params=StreamableHTTPConnectionParams(
                url=endpoint,
                headers=headers,
                timeout=settings.tool_timeout_s,
                sse_read_timeout=settings.tool_timeout_s,
            ),
            tool_filter=list(MCP_READ_TOOL_NAMES),
        )
    return McpToolset(
        connection_params=StdioConnectionParams(
            # Use the current interpreter so it works inside the project's virtualenv.
            server_params=StdioServerParameters(command=sys.executable, args=["-m", "agent.mcp_server"]),
            timeout=settings.tool_timeout_s,
        ),
        tool_filter=list(MCP_READ_TOOL_NAMES),
    )

The exact excerpt is build-checked against mcp_client.py. The HTTP branch carries the course deadline as both timeout and sse_read_timeout, plus an optional bearer token: a secret string sent in an Authorization header. Here is the handshake the two branches share:

sequenceDiagram
    participant C as McpToolset (client)
    participant S as MCP server<br/>(stdio child or gateway -> agentops-mcp)
    C->>S: initialize
    S-->>C: capabilities
    C->>S: tools/list
    S-->>C: six tool schemas
    Note over C: schemas enter the model context at runtime
    C->>S: tools/call get_incident
    S-->>C: result (untrusted data)

The tools/list step is where dynamic discovery earns its keep: schemas enter the model context at connection time, not at import time. That is the same reason a third-party server is a prompt-injection surface — its tool descriptions become part of your prompt.

What does the stdio transport actually launch?

The helper's local transport is not a network call. McpToolset builds a StdioServerParameters and launches an actual child process for the toolset instance:

# simplified
server_params=StdioServerParameters(command=sys.executable, args=["-m", "agent.mcp_server"])

One consequence to hold on to: one child per toolset. Constructing the helper without a URL spawns python -m agent.mcp_server and talks JSON-RPC over stdin/stdout; closing it tears the child down. The repository exposes and tests that adapter, but no conversational task selects it: the account-free first agent run uses direct Python tools, while mise run mcp demonstrates the standalone stdio server.

Deeper: why the command is sys.executable and not bare python

sys.executable, not "python". The source comment says "use the current interpreter so it works inside the project's virtualenv." Bare python could resolve to a different interpreter than the locked uv environment the agent runs in, importing a different agent package or missing dependencies entirely. sys.executable is the interpreter already running, so the child inherits the same pinned environment.

The pitfall to avoid is treating stdio as a production topology: it couples tool availability to spawning a subprocess on every agent host, with no place to enforce policy. That is precisely the coupling the HTTP-plus-gateway path removes.

When does the root agent use MCP?

Local/offline development registers the six Python read tools directly. When AGENT_MCP_URL is present, the composition root replaces them with one remote McpToolset:

# simplified
def _read_tools() -> list[ToolUnion]:
    """Use local tools by default and the governed MCP route when configured."""
    if settings.mcp_url:
        return [ops_mcp_toolset(settings.mcp_url)]
    return [*ALL_TOOLS, *KNOWLEDGE_TOOLS]

Only the six reads move. The guarded writes (ACTION_TOOLS), long-term memory (MEMORY_TOOLS), and instruction-only skills (skill_toolset()) are appended to the agent's tool list regardless of branch, so they always stay in-process:

flowchart TD
    Q{"settings.mcp_url set?"}
    Q -->|no| Local["[*ALL_TOOLS, *KNOWLEDGE_TOOLS]<br/>six reads in-process, with_resilience"]
    Q -->|yes| Remote["[ops_mcp_toolset(url)]"]
    Remote --> HTTP["streamable HTTP + optional bearer"]
    HTTP --> GW["agentgateway:3000/mcp"]
    GW --> Srv["agentops-mcp:8000<br/>six read tools"]
    Local --> Always["always in-process:<br/>ACTION_TOOLS, MEMORY_TOOLS, skill_toolset()"]
    Srv --> Always

In Kubernetes:

AGENT_MCP_URL=http://agentgateway:3000/mcp

On the host with the loopback wrapper, use http://127.0.0.1:3000/mcp. The deployed call path is agent -> agentgateway -> agentops-mcp:8000.

What changes when you flip AGENT_MCP_URL?

Flipping one variable moves the read tools across a process boundary. Four properties change with them, and none of the four show up in the agent's behavior until something goes wrong:

  • Discovery becomes runtime, not import-time. In-process, the six tool schemas are the Python signatures at import. Over MCP, the client performs tools/list on connect, so the schemas the model sees come from the live server — a version skew between agent and server surfaces here.
  • Resilience bounds move to the connection. The in-process reads are wrapped in with_resilience (bounded retries plus a deadline). The remote toolset is not: its only bound is the connection deadline set from settings.tool_timeout_s (default 30s) as both timeout and sse_read_timeout. There is no local exponential-backoff retry across the network — that is now the gateway's job.
  • A dependency can now fail the turn. The in-process path cannot be "down". Over MCP, an unreachable server or gateway fails the tool call — fast, at the deadline, rather than hanging the turn — but it is a failure the local path simply does not have.
  • Auth is a bearer header, when set. AGENT_MCP_TOKEN is a SecretStr; it becomes an Authorization: Bearer … header only when set, and the default local route sends no header. That is what a secured gateway route (5.5. Gateway Security) authenticates.

The switch is validated fail-fast at startup, not lazily mid-turn. A non-http(s) value raises before the agent serves a single request, with a message that names the fix:

# simplified
if self.mcp_url and not self.mcp_url.startswith(("http://", "https://")):
    problems.append(
        f"AGENT_MCP_URL must be an http(s) URL such as http://127.0.0.1:3000/mcp, got {self.mcp_url!r}. "
        "Unset it to call the six read tools directly, in process."
    )

This is the "parse, don't validate" discipline from config.py: a bad switch is a configuration error you see at boot, not a stack trace deep inside a user's incident triage.

Why put agentgateway between the client and server?

Chapter 5 adds a stable policy point for tool allowlists, fail-closed behavior, rate limits, bearer-token auth, logs, and traces. The Python tool implementation stays the same; only the configured MCP endpoint changes from a stdio child to http://…/mcp. That is the whole payoff of making tool calls protocol messages: the enforcement point is infrastructure you can operate, not code you have to fork.

How would you add a third-party MCP server safely?

Optional exercise: make the injection-surface warning above concrete by wiring a second server and admitting only the tools you have reviewed.

  • Mode: keep.
  • Goal: register a second McpToolset for an external read-only server (for example the reference filesystem MCP server) alongside the ops toolset, and admit only an explicit allowlist of its tools so its other tool descriptions never enter the model context.
  • Files to touch: agents/python/src/agent/config.py, mcp_client.py, and composition.py, plus agents/python/tests/test_mcp.py. The second factory passes tool_filter=[...]; composition stays behind an explicit setting.
  • Preflight: require git diff --quiet -- agents/python/src/agent/config.py agents/python/src/agent/mcp_client.py agents/python/src/agent/composition.py agents/python/tests/test_mcp.py.
  • Gate that proves completion: a test asserts the composed read surface is exactly the reviewed union — the six ops tools plus only the allowlisted external names — and that a tool the server offers but you did not allowlist is absent. Then note, in one sentence, which Chapter 5 gateway allowlist entry (5.2. MCP Gateway) would enforce the same boundary at the data plane rather than in application code.
  • Final state: keep only the four named files, with the external server disabled by default and its tests using a fake server; do not leave an external process or credential running.

What proves this page worked?

cd agents/python
uv run pytest tests/test_mcp.py tests/test_tools.py

That command verifies, with no model in the loop:

  • Exactly six tools, each annotated read-only, all supported transports, and rejection of an unknown transport.
  • Both protocol eras: a handshake client negotiates over HTTP, and a 2026-07-28 client lists and calls the tools with no handshake.
  • DNS-rebinding rejection of an untrusted Host (421), and rejection of an empty MCP_ALLOWED_HOSTS override.
  • Stdio and HTTP construction, with the bearer header present with a token and absent without one.
  • The bounded SIGTERM drain, the /healthz (200/503) and /livez readiness contracts, and conditional root-agent composition.

A pass ends with pytest's passed summary line and a zero exit. Run mise run test before leaving the chapter to apply the 95% combined line-and-branch coverage gate to the complete suite. A test that merely opens TCP port 8000 is not an MCP protocol test.

After the live readiness check, return to the terminal running mise run mcp:http and press Ctrl-C. Confirm the command exits before continuing; Chapter 5 starts the server again when the network boundary matters.

You are done when:

  • mise run mcp:http stayed up while curl -fsS http://127.0.0.1:8000/livez returned {"status":"alive"} from a second terminal, then stopped cleanly with Ctrl-C.
  • You can say why /healthz may answer 503 on a fresh checkout while /livez still answers, and why that ordering is correct.
  • uv run pytest tests/test_mcp.py tests/test_tools.py reports every test passed.
  • You can name the four things that change when AGENT_MCP_URL is set, without reopening that section.
  • You can name the two MCP revisions this server answers, the one ADK's client negotiates, and the three client capabilities this course deliberately does not declare.

Continue to 3.4. Memory when you can explain why the six reads cross the MCP boundary and the two writes never do.